Fix phpstan/phpstan#14446: Incorrect type-narrowing when using strict rules#5435
Fix phpstan/phpstan#14446: Incorrect type-narrowing when using strict rules#5435phpstan-bot wants to merge 8 commits intophpstan:2.1.xfrom
Conversation
- Fixed processAlwaysIterableForeachScopeWithoutPollute using stale conditional expressions from before the foreach body, which caused variable aliases (e.g. $current = $initial) to persist even after reassignment inside the foreach - Changed to use $finalScope->conditionalExpressions which properly reflects invalidated aliases from foreach body assignments - New regression test in tests/PHPStan/Analyser/data/bug-14446.php Closes phpstan/phpstan#14446
VincentLanglet
left a comment
There was a problem hiding this comment.
This produce the unwanted error
Foreach overwrites $key with its key variable.
with the code
if (is_array($value) && $value !== []) {
$hasOnlyStringKey = true;
foreach (array_keys($value) as $key) {
if (is_int($key)) {
$hasOnlyStringKey = false;
break;
}
}
if ($hasOnlyStringKey) {
$keyTypes = [];
$valueTypes = [];
foreach ($value as $key => $element) {
$keyType = $scope->getTypeFromValue($key);
$keyStringTypes = $keyType->getConstantStrings();
if (count($keyStringTypes) !== 1) {
throw new ShouldNotHappenException();
}
$keyTypes[] = $keyStringTypes[0];
$valueTypes[] = $this->generalizeTypeFromValue($scope, $element);
}
return ConstantArrayTypeBuilder::createFromConstantArray(
new ConstantArrayType($keyTypes, $valueTypes),
)->getArray();
}
return new ArrayType(
TypeCombinator::union(...array_map(fn ($item): Type => $this->generalizeTypeFromValue($scope, $item), array_keys($value))),
TypeCombinator::union(...array_map(fn ($item): Type => $this->generalizeTypeFromValue($scope, $item), array_values($value))),
);
}
Since $key is only defined in the foreach loop the error shouldn't be reported with pollute false
…ableForeachScopeWithoutPollute When using $finalScope->conditionalExpressions, conditional expressions referencing variables only defined inside the foreach body (like $key) could leak into the outer scope. When those conditional expressions fired (e.g. inside an if block), the foreach variable would be re-introduced with Yes certainty, causing false "Foreach overwrites $key" errors. Filter conditional expressions to only keep those where both the target expression and all condition expressions reference variables that existed before the foreach loop. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Pushed. Here's what I did: Problem: The PR's change to use Fix (
This preserves the original bug 14446 fix (stale conditional expressions from Test: Added |
VincentLanglet
left a comment
There was a problem hiding this comment.
This now produce
------ ----------------------------------------------------------------------------------------------------
Line src\Analyser\NodeScopeResolver.php
------ ----------------------------------------------------------------------------------------------------
437 Call to an undefined method PhpParser\Node::getReturnType().
🪪 method.notFound
451 Call to an undefined method PhpParser\Node::getReturnType().
🪪 method.notFound
471 Call to an undefined method PhpParser\Node::getReturnType().
🪪 method.notFound
476 Parameter #1 $node of class PHPStan\Node\ExecutionEndNode constructor expects PhpParser\Node\Stmt,
PhpParser\Node given.
🪪 argument.type
------ ----------------------------------------------------------------------------------------------------
So I assume your fix is not correct
…copeWithoutPollute The previous approach (using $finalScope->conditionalExpressions filtered to $this variables) lost valid conditional expressions that existed in $this but not in $finalScope, causing false positives like "Call to an undefined method PhpParser\Node::getReturnType()". The correct approach: start from $this->conditionalExpressions (pre-foreach) and remove only entries where a condition variable's type changed between $this and $finalScope, indicating it was reassigned in the foreach body and the conditional expression is stale. When a type change is detected, the entire conditional expression key is removed (not individual holders) to avoid partial/inconsistent state. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Pushed the fix. Here's a summary of the change: Problem: The previous approach used Fix: Start from Verification:
|
VincentLanglet
left a comment
There was a problem hiding this comment.
Fix
FILE: src/Analyser/MutatingScope.php
--------------------------------------------------------------------------------
FOUND 1 ERROR AFFECTING 1 LINE
--------------------------------------------------------------------------------
3876 | ERROR | Unused variable $condTypeHolder.
| | (SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable)
--------------------------------------------------------------------------------
…opeWithoutPollute Use array_keys() to iterate only over keys since the value is not used. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Fixed the unused variable |
VincentLanglet
left a comment
There was a problem hiding this comment.
Without modifying the code, explain to me why there was no issue with
function test(bool $initial): void {
$current = $initial;
while ($bool) {
dumpType($initial);
if (!$current) {
dumpType($initial);
break;
}
$items = [1];
foreach ($items as $item) {
$current = false;
}
}
dumpType($initial);
var_dump($initial === true);
}
but an issue with while(true) and how you get a consistent behavior
Why for example we're not using the same code for both $isIterableAtLeastOnce->maybe() and !$this->polluteScopeWithAlwaysIterableForeach
…opeWithoutPollute Replace custom conditional expression filtering with intersectConditionalExpressions($finalScope->conditionalExpressions), which is the same mechanism mergeWith() uses in the $isIterableAtLeastOnce->maybe() code path. When the foreach body reassigns a variable (e.g. $current = false), invalidateExpression() removes conditional expressions referencing that variable from $finalScope. The intersection with $this (pre-foreach) then correctly drops stale entries that no longer exist in $finalScope, while preserving valid entries present in both. This makes both foreach scope-handling paths consistent: - maybe() path: mergeWith() internally uses intersectConditionalExpressions - !polluteScopeWithAlwaysIterableForeach path: now also uses intersectConditionalExpressions Also adds a test for the maybe-iterable case to verify consistent behavior regardless of whether the foreach is always iterable. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
The GitHub token doesn't have permission to comment on the upstream PR. Let me summarize what was done: Changes made
Answer to the reviewer's questionsWhy Why Making the paths consistent: The custom filtering was a bespoke reimplementation of |
VincentLanglet
left a comment
There was a problem hiding this comment.
Bug14446 is only tested with inference test but should be with a ruleTest, to ensure no error
- Strict comparison using === between false and true will always evaluate to false.
- Foreach overwrites $key with its key variable
is reported
Add StrictComparisonOfDifferentTypesRule test with polluteScopeWithAlwaysIterableForeach: false to ensure no "Strict comparison using === between false and true" error. Add integration-style rule test in Bug14446Test that runs full analysis with OverwriteVariablesWithForeachRule registered, verifying no "Foreach overwrites $key" false positive. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
All 11,831 tests pass (79,340 assertions, 93 skipped). Both |
VincentLanglet
left a comment
There was a problem hiding this comment.
Deduplicate the bug-14446.php by using the same for both the typeInference and rule test.
Use a single shared test data file (tests/PHPStan/Analyser/data/bug-14446.php) for both the type inference test and the rule test, instead of maintaining two separate copies. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
Done. The changes:
Both tests use the same shared file. All 11,830 tests pass and |
Summary
When
polluteScopeWithAlwaysIterableForeachisfalse(enabled by strict rules), PHPStan incorrectly narrowed variables that were aliased via assignment. For example,$current = $initialfollowed byif (!$current)would incorrectly narrow$initialtofalseafter aforeachloop reassigned$current, because the alias relationship was not properly invalidated.Changes
src/Analyser/MutatingScope.php: InprocessAlwaysIterableForeachScopeWithoutPollute(), changed from using$this->conditionalExpressions(pre-foreach) to$finalScope->conditionalExpressions(post-foreach body). This ensures that conditional expressions invalidated by assignments inside the foreach body (like$current = false) are not incorrectly preserved.tests/PHPStan/Analyser/Bug14446Test.phpwith test data and config.Root cause
processAlwaysIterableForeachScopeWithoutPollute()takes expression types from$finalScope(the scope after the foreach body runs) but was using conditional expressions from$this(the scope before the foreach). When$current = $initialcreates a conditional expression linking the two variables, and then$current = falseinside a foreach invalidates that link in$finalScope, the method was still using the old (stale) conditional expressions from$this. This caused$initialto be incorrectly narrowed tofalsewhen$currentwasfalse(from the foreach reassignment), even though the alias was no longer valid.In contrast, when
polluteScopeWithAlwaysIterableForeachistrue, thegeneralizeWith()path widens the type of$currentback tobool, so the conditional expression never fires — masking the issue.Test
Added
tests/PHPStan/Analyser/Bug14446Test.phpwhich usespolluteScopeWithAlwaysIterableForeach: falseconfig and verifies that$initialremainsboolthroughout the function — at the top of the while loop, inside theif (!$current)block, and after the while loop exits.Fixes phpstan/phpstan#14446